feat: calsync v1 implementation#1
Merged
Merged
Conversation
…ncy key derivation
… resolution Add Engine.Reconcile covering the daily self-healing cycle: FullResync for every watched calendar, resolution of mappings left pending across a crash, adoption/cleanup of tagged orphan blockers via ListBlockers, and re-evaluation of suppressed mappings whose duplicate real event has since disappeared. Per-calendar/account errors are joined so one account's failure does not block the rest. Also adds a regression test verifying that a failed CreateBlocker during pending resolution leaves the mapping pending (not active) and that it recovers cleanly on the next Reconcile once the failure clears.
Prevent FullResync's own upsertBlockers retry from pre-resolving the pending mapping under test, so the CreateBlocker-failure assertions exercise resolvePending exclusively rather than a mix of both paths.
…mapping and normalization Implements internal/provider/google.Client.Changes: full sync (timeMin/timeMax/singleEvents) vs incremental sync (syncToken only), full-page-completion-only nextSyncToken handling, 410 GONE -> provider.ErrCursorInvalid, 403/429 usageLimits exponential backoff+jitter retry (max 5), and event normalization (cancelled, transparency, self-declined attendee, extendedProperties.private calsync-origin, UTC dateTime, all-day date). Promotes google.golang.org/api to a direct dependency (it was already present as indirect). Doing so pulls in the transitive deps its calendar/v3, googleapi and option subpackages require, and forces the go directive from 1.25.0 to 1.25.8 because google.golang.org/api v0.287.0's own go.mod declares `go 1.25.8`; this is a hard MVS requirement with no `go get`/toolchain-directive workaround at the pinned version, so the go directive bump could not be avoided.
Google provider only mapped 410 to ErrCursorInvalid; invalid_grant / interaction_required (RFC6749 token errors, arriving wrapped in *url.Error via oauth2.Transport) and googleapi 401 never surfaced as provider.ErrAuthExpired, so the engine's reauth_required transition (spec 9.3) never triggered on real token revocation. Add provider.NormalizeAuthErr as a provider-agnostic helper (usable by the future microsoft provider) that unwraps oauth2.RetrieveError via errors.As and wraps invalid_grant/interaction_required in ErrAuthExpired, preserving the original error via %w. google-specific 401 detection stays in the google package and delegates the rest to NormalizeAuthErr, keeping googleapi out of provider/autherr.go.
…ne lookup Implements CreateBlocker/UpdateBlocker/DeleteBlocker/ListBlockers/ GetCalendarTimezone, completing provider.Provider for the google package. CreateBlocker uses a client-generated idempotent event ID and reconciles 409 conflicts by fetching the existing event. DeleteBlocker treats 404/410 as success. All error paths through doWithRetry are normalized via normalizeAuthErr so 401/invalid_grant map to provider.ErrAuthExpired, consistent with Changes (Task 14).
…andling Implements internal/provider/microsoft: client base (microsoft.go) with Prefer: IdType="ImmutableId" headers, 429/5xx retry with injectable sleep, and Changes (delta.go) covering calendarView delta paging, @removed/ isCancelled -> Deleted, syncStateNotFound/410 -> ErrCursorInvalid, and a 50-page/identical-page circuit breaker. Also normalizes Graph HTTP 401 and oauth2.RetrieveError (invalid_grant/interaction_required) to provider.ErrAuthExpired via the shared doRead path, per Task 14 review follow-up.
url.Values.Encode() encodes spaces as "+", but Microsoft Graph's OData parser rejects "+" as a space substitute inside $filter/$expand (known Graph behavior). The extended-property id contains spaces, so findBlockerByOriginTag, ListBlockers and getBlockerRecord could fail against the real API even though httptest-based tests passed (Go's net/url symmetrically decodes "+" back to space via Query()). Add encodeQuery() to rewrite the encoded "+" to "%20" and use it at all three call sites. Add RawQuery-based tests (Query() cannot detect the bug) confirming no literal "+" reaches the wire.
The 409 conflict path in CreateBlocker unconditionally returned the ID found by events.get, even when that event was cancelled. Google keeps deleted events around in cancelled state and returns 409 again for the same deterministic idempotent ID, so a busy->free->busy delete+recreate cycle would adopt an invisible cancelled event into the active mapping and the blocker would never reappear. Now the 409 path checks events.get's status: if cancelled, it revives the event via events.update using the same body events.insert would have sent (minus the ID), sharing that body construction between the two call sites.
Spec section 8.4 requires that a blocker be recreated whenever its origin event is still alive, even if someone deleted the blocker by hand. No reconcile phase implemented this: upsertBlockers' time_hash match case never calls the provider, so a manually deleted blocker whose origin is unchanged was never detected by normal sync. Add a restoreMissingBlockers phase, run after adoptOrphans and before reevaluateSuppressed. For each account it lists real blockers to build the set of live IDs, then re-creates via the existing createFromMapping helper any active mapping whose BlockerEventID is missing from that set, using the cached origin event. If the origin isn't cached, it's skipped and left for the next FullResync.
OriginTag encodes as "<account_id>:<event_id>", and parseOriginTag splits on the first colon. An account id containing a colon would make adoption misparse the tag and could cause it to delete a legitimate blocker it mistakes for an orphan. Reject such ids at config load time.
…zily The calendar.events OAuth scope cannot call calendars.get (403 in real environments), so GetCalendarTimezone now reads the timeZone field of the events.list response envelope (maxResults=1), which the events scope can access. Microsoft's /me/mailboxSettings/timeZone requires MailboxSettings.Read, so that delegated scope is added to the OAuth config and documented in the Entra setup steps in the README. The engine also only fetches the target timezone for all-day blockers now: timed blockers are written in fixed UTC on both providers, so fetching a timezone for every event was a wasted API call and an unnecessary scope dependency.
Map HTTP 404 from events.patch (Google) and PATCH /me/events (Graph) to provider.ErrNotFound. When the engine's time-hash-mismatch branch receives ErrNotFound, the blocker was deleted out-of-band (e.g. by hand), so upsertBlockers now falls back to createFromMapping, which re-creates it through the usual pending -> CreateBlocker -> active flow instead of failing the whole sync cycle. Complements the restoreMissingBlockers reconcile phase (0efba52): that phase covers unchanged origins once a day, this fallback covers the update path immediately.
A target account's expired token used to surface as a bare
ErrAuthExpired from the origin account's sync, so scheduler.tick
blamed the origin, marked it reauth_required and stopped its polling
entirely (spec 9.3 violation: an expired account must only stop
itself).
Introduce TargetAuthError{AccountID, Err}. Blocker writes to a target
(upsertBlockers, deleteBlockersForOrigin, createFromMapping) wrap
ErrAuthExpired in it, skip just that target, keep distributing to the
remaining targets and return the collected errors via errors.Join.
SyncCalendar and FullResync treat events whose failures consist only
of TargetAuthErrors as non-fatal: processing continues and the cursor
still advances (the pending mapping plus reconcile backfill the
expired target after reauth).
scheduler.tick walks the error tree, records reauth_required on every
calendar of each expired *target* account and skips it on later ticks;
the origin's own expiry (ErrAuthExpired outside any TargetAuthError)
is still attributed to the origin as before.
…g blocker The 409 fallback resurrection (58edfac) updated the cancelled event without a status field, relying on events.update resetting omitted fields to their defaults. Send status: "confirmed" explicitly so the revival does not depend on that behavior.
…rification The all-day date extraction assumes Graph delta returns the local calendar date; whether the real API converts it to UTC (shifting the date) must be verified against the spike list in spec chapter 15.
resolvePending, adoptOrphans, restoreMissingBlockers and reevaluateSuppressed returned on the first error, so a single failing account or mapping aborted the whole phase for every other account (spec 10: one calendar's failure must not stop the cycle). They now collect errors per account/row with errors.Join and keep going, the same pattern Reconcile already uses for the FullResync loop. The adoptOrphans loop body moves into an adoptOrphan helper so one blocker's failure skips just that record.
busy_show_as now only accepts the Graph freeBusyStatus enum (free, tentative, busy, oof, workingElsewhere, unknown); a typo used to be silently treated as "never busy". sync --once records a successful per-calendar sync via SetCalendarError(ref, "") so calsync status reflects one-shot syncs the same way the daemon tick does.
There was a problem hiding this comment.
Pull request overview
calsync v1 の同期デーモン実装として、Google / Microsoft 365 カレンダーを差分ポーリングで監視し、Busy 予定を他アカウントへ「予定あり」ブロッカーとしてミラーする一連の基盤(エンジン/ストア/認証/CLI/配布)を追加する PR です。
Changes:
- SQLite(WAL+flock) を用いた状態管理(events/mappings/calendars)と、同期/リコンサイルに必要な CRUD・検索APIを追加
- Google syncToken / Microsoft Graph deltaLink による差分取得、ブロッカー作成/更新/削除/列挙、認証失効の正規化を含むプロバイダ実装を追加
- OAuth(Loopback+PKCE / Device Code)・トークン永続化、CLI(run/sync/reconcile/status/doctor/auth/accounts)および Docker 配布・README を追加
Reviewed changes
Copilot reviewed 56 out of 57 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | セットアップ/運用手順、制約、CLI、トラブルシュートを追加 |
| internal/store/store.go | SQLite ストア(Open/ReadOnly/Close)と calendars 管理を実装 |
| internal/store/store_test.go | store Open/ReadOnly と calendars 操作のテストを追加 |
| internal/store/mappings.go | mappings の upsert/取得/列挙/判定APIを追加 |
| internal/store/mappings_test.go | mappings API のテストを追加 |
| internal/store/events.go | events キャッシュの upsert/取得/検索APIを追加 |
| internal/store/events_test.go | events API のテストを追加 |
| internal/provider/provider.go | Provider インターフェイスと共通エラーを定義 |
| internal/provider/microsoft/microsoft.go | Graph クライアント基盤(do/doRead)、Graph 時刻処理を追加 |
| internal/provider/microsoft/delta.go | /calendarView/delta による差分取得(カーソル/ループ検知)を実装 |
| internal/provider/microsoft/blockers.go | Graph ブロッカー CRUD/ListBlockers/TZ取得を実装 |
| internal/provider/google/google.go | Google Calendar クライアント基盤(リトライ/認証失効正規化)を追加 |
| internal/provider/google/changes.go | events.list による差分取得(syncToken/410処理)を実装 |
| internal/provider/google/blockers.go | Google ブロッカー CRUD/ListBlockers を実装 |
| internal/provider/fake/fake.go | エンジンテスト用の in-memory Provider を追加 |
| internal/provider/fake/fake_test.go | fake Provider の契約テストを追加 |
| internal/provider/autherr.go | OAuth2 refresh 失敗の ErrAuthExpired 正規化を追加 |
| internal/provider/autherr_test.go | NormalizeAuthErr のテストを追加 |
| internal/model/model.go | 共通モデル(Window/Event/Blocker)とハッシュ/冪等キー生成を追加 |
| internal/model/model_test.go | TimeHash/冪等キー/Window.Contains のテストを追加 |
| internal/engine/scheduler.go | ポーリング+日次リコンサイルのスケジューラを追加 |
| internal/engine/remove_test.go | accounts remove のエンジンテストを追加 |
| internal/engine/errors.go | ターゲット認証失効の誤帰属防止(TargetAuthError)を追加 |
| internal/engine/dedupe.go | 同一会議の重複抑止・suppressed 昇格ロジックを追加 |
| internal/engine/dedupe_test.go | 重複抑止/昇格のテストを追加 |
| internal/engine/classify.go | ブロック対象/ウィンドウ判定のヘルパを追加 |
| internal/engine/classify_test.go | classify の境界条件テストを追加 |
| internal/config/config.go | YAML 読み込み/検証/デフォルト補完、Window/Targets API を追加 |
| internal/config/config_test.go | config 検証のテストを追加 |
| internal/auth/tokens.go | トークン永続化(0600)と refresh ローテーション追従を追加 |
| internal/auth/tokens_test.go | TokenStore/PersistingTokenSource のテストを追加 |
| internal/auth/flow.go | ループバック+PKCE / Device Code の OAuth フローを追加 |
| internal/auth/flow_test.go | OAuth フローのテストを追加 |
| go.mod | 依存関係と Go バージョン指定を追加 |
| docs/superpowers/specs/2026-07-03-calsync-design.md | OAuth スコープ等の設計記述を更新 |
| docs/superpowers/plans/2026-07-03-calsync-v1.md | 実装計画の Go バージョン等を更新 |
| Dockerfile | distroless 向け multi-stage ビルドを追加 |
| docker-compose.yaml | compose 例(TZ/volume)を追加 |
| cmd/calsync/main.go | ルートCLI、provider/engine 組み立て、孤児検出ヘルパを追加 |
| cmd/calsync/cmd_sync.go | sync --once コマンドを追加 |
| cmd/calsync/cmd_status.go | status コマンド(読み取り専用)を追加 |
| cmd/calsync/cmd_run.go | run デーモン起動を追加 |
| cmd/calsync/cmd_reconcile.go | reconcile コマンドを追加 |
| cmd/calsync/cmd_doctor.go | doctor 診断コマンドを追加 |
| cmd/calsync/cmd_auth.go | auth add/list を追加 |
| cmd/calsync/cmd_accounts.go | accounts remove を追加 |
| cmd/calsync/cli_test.go | CLI ヘルパ(findOrphanAccounts/renderStatus/doctor)のテストを追加 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+176
to
+182
| func (c *Client) ListBlockers(ctx context.Context, cal model.CalendarRef, window model.Window) ([]model.BlockerRecord, error) { | ||
| q := url.Values{} | ||
| q.Set("$filter", fmt.Sprintf( | ||
| "singleValueExtendedProperties/Any(ep: ep/id eq '%s' and ep/value ne null)", | ||
| originPropertyID)) | ||
| listURL := c.baseURL + "/me/events?" + encodeQuery(q) | ||
|
|
Comment on lines
+150
to
+165
| func (t graphTime) utc() (time.Time, error) { | ||
| const layout = "2006-01-02T15:04:05.9999999" | ||
| loc := time.UTC | ||
| if t.TimeZone != "" && t.TimeZone != "UTC" { | ||
| l, err := time.LoadLocation(t.TimeZone) | ||
| if err != nil { | ||
| return time.Time{}, fmt.Errorf("graph time: unknown timeZone %q: %w", t.TimeZone, err) | ||
| } | ||
| loc = l | ||
| } | ||
| parsed, err := time.ParseInLocation(layout, t.DateTime, loc) | ||
| if err != nil { | ||
| return time.Time{}, fmt.Errorf("graph time: parse %q: %w", t.DateTime, err) | ||
| } | ||
| return parsed.UTC(), nil | ||
| } |
Comment on lines
+134
to
+144
| func (s *Store) Close() error { | ||
| dbErr := s.db.Close() | ||
| var unlockErr error | ||
| if s.lock != nil { | ||
| unlockErr = s.lock.Unlock() | ||
| } | ||
| if dbErr != nil { | ||
| return dbErr | ||
| } | ||
| return unlockErr | ||
| } |
Comment on lines
+54
to
+55
| CREATE INDEX IF NOT EXISTS idx_events_icaluid ON events (ical_uid, start_utc); | ||
|
|
Comment on lines
+80
to
+92
| case resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries: | ||
| wait := backoff | ||
| if ra := resp.Header.Get("Retry-After"); ra != "" { | ||
| if secs, perr := strconv.Atoi(ra); perr == nil && secs >= 0 { | ||
| wait = time.Duration(secs) * time.Second | ||
| } | ||
| } | ||
| resp.Body.Close() | ||
| c.sleep(wait) | ||
| case resp.StatusCode >= 500 && attempt < maxRetries: | ||
| resp.Body.Close() | ||
| c.sleep(backoff) | ||
| backoff *= 2 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概要
calsync v1 の全実装(47 commits)。複数の Google / Microsoft 365 カレンダーを差分ポーリングで相互監視し、Busy 予定を他の全アカウントへ「予定あり」ブロッカーとしてミラーするセルフホスト型デーモン。
設計書:
docs/superpowers/specs/2026-07-03-calsync-design.md/ 実装計画:docs/superpowers/plans/2026-07-03-calsync-v1.md(いずれも main に含まれるため本 PR の diff は実装のみ)主な内容
internal/engine): 差分処理の決定則(仕様 6.1)、mappings 一次判定+拡張プロパティタグ二次判定のループ防止、intent-first の冪等ブロッカー作成、iCalUID による同一会議の重複抑止と昇格、set-difference リコンサイル(カーソル失効時も mappings 非破壊)、日次リコンサイル(孤児収容・pending 解決・手動削除ブロッカーの復元)、reauth_required のアカウント単位分離internal/provider/{google,microsoft}): Google syncToken 差分(併用禁止パラメータ遵守・410 リカバリ・409 蘇生)/ Graph calendarView delta(ImmutableId 統一・maxpagesize 回避+サーキットブレーカー・@removed ノイズ耐性・OData %20 エンコード)。認証失効はErrAuthExpiredに正規化internal/auth): ループバック + PKCE(Google/MS 共通)、Device Code(MS)、refresh token ローテーション追従の永続化internal/store): SQLite(WAL・flock 多重起動防止・status/doctor 用の読み取り専用オープン)cmd/calsync): run / sync --once / reconcile / status / doctor / auth add・list / accounts remove(--force)実行済みチェック
go test ./... -race -count=1全10パッケージ PASS /go vet/gofmt -lクリーン /CGO_ENABLED=0 go build ./...成功 /docker compose config -q成功リスク・未検証事項
docker build/docker runは Docker デーモン不在のため未実行(構文・ビルドの代替確認のみ)